feat: clinical fidelity hardening — population roles, real exclusions, value-set safety flags#3
Merged
Merged
Conversation
…ent Phase 5 session)
…dge seed)
Copies 1,545 value sets / 225,261 codes / 72 measures / 1,597 measure-value-sets
from parthenon app.vsac_* to medgnosis phm_edw.vsac_* via \copy TO STDOUT | FROM STDIN.
Seeds the measure_value_set bridge (44 of 45 CMS measures; CMS249v6 has no VSAC entry).
EDW joinability confirmed:
conditions (SNOMEDCT): 89 distinct codes
medications (RXNORM): 228 distinct codes
observations (LOINC): 43 distinct codes in sampled 5k rows (full scan
blocked by 1B-row table; no index on observation_code)
Source-consistency: OID 2.16.840.1.113883.3.464.1003.103.12.1001 (Diabetes)
medgnosis count = 774, parthenon count = 774. Exact match.
Pure Wilson score interval (wilsonCI) for binomial proportions — preferred over normal approximation for the small panels Medgnosis serves; bounds are always clamped to [0, 1]. 5 Vitest tests, all passing; tsc clean.
Implements vsacService.ts with listValueSets, getValueSetCodes, getMeasureValueSets, and resolveMeasureCodes over phm_edw.vsac_* tables and the measure_value_set bridge. Uses inline NULL-coalescing for optional filters to avoid nested sql`` fragments. EDW_CODE_SYSTEM maps domains to verified VSAC code systems (condition/procedure = SNOMEDCT, not ICD-10/CPT). TDD: 7/7 tests pass. Live DB: CMS122v12 resolves 2,704 SNOMEDCT codes across 26 value sets.
… /:oid/codes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n 051)
- Migration 051: creates phm_star.fact_measure_strata (SERIAL PK, measure_key,
date_key_period, dimension, stratum, denominator, numerator, excluded, created_at)
with idx_fms_measure index. Rebuilt atomically with fact_measure_result.
- measureCalculatorV2: single-pass GROUPING SETS produces 'all' + 'age_band' +
'gender' strata in one subquery scan (no extra round-trip). TRUNCATE of both
tables inside the same transaction — strata and results never diverge.
Statement timeout raised 30s → 60s to accommodate the added strata INSERT.
- getMeasureSummary: adds ci_lower/ci_upper fields (Wilson 95% CI, percent ×1 decimal)
via wilsonCI(). Returns null for measures with zero eligible patients.
Refresh timings (26967 fact rows, live DB):
First run: { rowCount: 26967, durationMs: 1596 }
Steady-state: { rowCount: 26967, durationMs: 231 }
Verification: dimensions=age_band,all,gender | violations=0 | mismatches=0
…terface Introduces MeasureEvaluator interface with sql (current) and cql (stub) implementations. Call sites in the BullMQ worker and admin routes now resolve the engine via getMeasureEvaluator() instead of calling refreshMeasureResults() directly. Engine selected by MEASURE_EVALUATOR env var (default: sql). 5 new unit tests, full suite 121/121 green, tsc clean.
Matches the referential policy of sibling star fact tables (ON DELETE RESTRICT). Applied to the live dev DB via equivalent ALTER TABLE since 051 was already recorded in _migrations there; fresh environments get it from the CREATE TABLE.
… review)
- resolveMeasureCodes header now warns it unions ALL population roles
(~82% of CMS122 SNOMEDCT codes are exclusion-family) — must not drive
population finding until the bridge carries population_role
- EDW_CODE_SYSTEM comment clarifies values are VSAC labels, not EDW
code_system column values ('SNOMED'/'ICD-10') — translate before joining
- load-vsac.sh verification now asserts source==destination counts and
exits non-zero on mismatch (was echo-only, could ship wrong data green)
Adds population_role (NOT NULL, default 'unclassified') and role_method
columns to phm_edw.measure_value_set with CHECK constraints. Heuristic
seeds 518 of 1,015 rows:
denominator_exclusion: 138 rows (hospice, palliative, advanced illness,
frailty, dementia medications)
initial_population: 248 rows (office visits, encounter types, wellness,
telehealth, preventive care)
supplemental: 132 rows (race, ethnicity, payer type)
unclassified: 497 rows (clinical condition/lab/medication sets
where role cannot be inferred from name alone)
CLINICAL SAFETY FIX: 'nursing facility' and 'long.term care' removed from
the exclusion regex — in this VSAC dataset those patterns match only
qdm_category='Encounter' types (Nursing Facility Visit, Discharge Services
Nursing Facility, Care Services in Long Term Residential Facility), which
are qualifying encounters for CMS128/135/139/142/143/144/145/149/156.
Labeling them denominator_exclusion would incorrectly suppress care gaps
for patients whose qualifying visit was in a nursing facility setting.
These three sets are now correctly classified as initial_population via
explicit name matching in the encounter UPDATE.
Hospice Encounter, Palliative Care Encounter, and Frailty Encounter match
BOTH heuristics but are protected by the 'AND population_role = unclassified'
guard on the encounter UPDATE — they stay denominator_exclusion (correct per
eCQM specs). Migration also annotates the role column with a clinical safety
comment.
resolveMeasureCodes now requires a PopulationRole third argument and adds
AND mv.population_role = ${role} to the query — callers must consciously
choose a role; silent denominator+exclusion union is impossible.
getMeasureBridgeStatus returns version_drift, per-role code counts, and
unclassified_count for a measure, enabling consumers to detect bridging gaps.
Export PopulationRole type and MeasureBridgeStatus interface.
Replace the pre-migration SAFETY warning comment with the post-052 contract:
role-aware resolver; 'unclassified' is audit-only, never a denominator.
Live CMS122v12 SNOMEDCT role distribution:
denominator_exclusion: 2114 (the contamination, now labeled and filterable)
initial_population: 30
unclassified: 564
Tests: RED (3 failing) → GREEN (10 passing). tsc --noEmit clean.
No callers of resolveMeasureCodes outside tests on this branch.
…ash-seeded demo rows Live run result: newlyExcluded=0, revertedToOpen=2689, durationMs=66344 - Demo data has 8 distinct condition codes matching denominator_exclusion VSAC sets but none are linked via active condition_diagnosis rows to care gap patients — so 0 newly excluded (correct: no clinical evidence). - All 2,689 hash-seeded excluded rows (migration 017) reverted to 'open'. - fact_patient_bundle_detail synced in same transaction (0 mismatches after). - Measure refresh: 26,967 rows, 305ms; exclusion_flag=0 in fact_measure_result. - Regression gate: 0 rows with exclusion_flag AND (denominator_flag OR numerator_flag). - care_gap: closed=6697, open=20270 (no excluded rows — honest, not suppressed). - Wired into nightly-scheduler.ts BEFORE measureQueue.add so each nightly refresh reads corrected bundle_detail statuses. Note: nightly-scheduler.ts is PR #1-era (predates Phases 5–8 additions on main). Rebase onto main will need trivial conflict resolution at the measureQueue.add site.
…lity-hardening # Conflicts: # apps/api/src/workers/nightly-scheduler.ts
…c a no-op on fresh checkouts On a clean clone tsc saw stale tsbuildinfo files with matching hashes and emitted nothing, so packages/db and packages/shared dist/ never existed. packages/solr then failed with TS2307 Cannot find module '@medgnosis/db'. - git rm --cached the three committed .tsbuildinfo files - Add *.tsbuildinfo to .gitignore to prevent re-committing
…y when ohdsi source unreachable The Synthea ETL pulls from a local 'ohdsi' database that only exists on the dev host. On CI (and any fresh environment) the dblink connection fails, aborting the migration. Restructured all dblink-dependent INSERTs into a single DO $$ ... EXCEPTION WHEN OTHERS THEN RAISE NOTICE ... END $$; block so that a connection failure skips the data load with a clear notice instead of failing. The TRUNCATE statements run unconditionally (they are idempotent on an empty DB). All SQL logic preserved exactly — only the exception envelope was added.
…T EXISTS Migration 010 already created phm_edw.clinical_note with a SERIAL PK and base SOAP columns. 012 used CREATE TABLE IF NOT EXISTS (silently skipped on fresh DB) then tried to create an index on author_user_id which doesn't exist in the 010 schema, causing a hard failure on every fresh checkout. Converted to idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS for the 8 columns 012 adds over 010.
The provider_schedule INSERT (and downstream care_team, order_set, etc.) all reference provider_id=2816 which only exists after the Synthea ETL runs. On CI / fresh DB this causes a FK violation that aborts the migration. Added EXCEPTION WHEN OTHERS to the existing DO block to skip gracefully with a NOTICE.
…ns on empty DB Parts A-D and Part H both INSERT into tables with FK to phm_edw.provider(2816) which doesn't exist on CI/fresh DB. Added EXCEPTION WHEN OTHERS guards to both DO blocks. Parts E-G and I-J are top-level INSERTs filtered by pcp_provider_id=2816 — they return 0 rows on empty DB so no FK error there.
…uppression (migration 053) Migration 053 seeds 3 clinical_rule rows binding NEW_ACEARB_NO_BMP to VSAC: - ACEARB_RXNORM_VALUE_SET_OID: 2.16.840.1.113883.3.526.2.39 (ACE Inhibitor or ARB or ARNI, 151 RXNORM codes) - ACEARB_SUPPRESS_VALUE_SET_OID x2: Allergy + Intolerance to ACE Inhibitor or ARB (SNOMEDCT) cohortFlags.ts: replaces 12-drug name regex with code join via VSAC value set OID loaded from clinical_rule at runtime. Adds allergy/intolerance suppression anti-join (0 patients suppressed in demo data — machinery is the deliverable). Fails loudly if rule rows missing. Before/after flag counts: NEW_ACEARB_NO_BMP 367→367, GFR_LOW 110→110 (count stable — RxNorm code join covers same medications as the regex in this demo dataset). Suppression evidence: 0 patients with documented ACE/ARB allergy/intolerance in demo data. Tests: 166 passing (21 files), up from 162 baseline (+4 new runCohortFlags tests). tsc: clean.
… doesn't exist) billing_claim table (defined in 011) has no org_id column. The INSERT in Part E included it causing a hard failure on every fresh checkout. Removed org_id from the column list and the corresponding subquery value.
…rder_datetime→start_datetime, etc.) medication_order (defined in 001) has no sig, quantity_dispensed, days_supply, refills, or order_datetime columns. Map to actual columns: dosage, refill_count, start_datetime. Literal defaults used for quantity/days_supply (not in schema).
Patient lookups by pcp_provider_id=2816 return NULL on CI/fresh DB, causing NOT NULL violations on cancer_staging and related tables. Added EXCEPTION WHEN OTHERS guard to the single DO block.
…s migration runner \echo and \i are psql-only meta-commands; the Node.js postgres driver throws a syntax error on them. This is a validation/reporting migration with no DDL. Removed all \echo output lines and the \i re-include of 014 (already ran as its own migration). All SELECT validation queries preserved — they return 0 rows on empty DB which is correct and harmless.
CONCURRENTLY cannot run inside a transaction block and the migration runner wraps each file in a transaction. Drop-in replacement: same DDL, no lock semantics needed on a fresh/empty DB. Production already has these indexes so this only affects fresh checkouts.
…to _migrations The runner (migrate.ts) already inserts the migration name after executing the SQL in the same transaction. 030's own INSERT into _migrations caused a unique constraint violation. Removed the redundant self-registration.
clinical_note.note_id is SERIAL (INT) per migration 010. The FK reference from note_coded_diagnosis(note_id UUID) caused a type mismatch that prevented constraint creation. Changed to INT to match the PK type.
clinical_note.note_id is SERIAL (INT); inserting gen_random_uuid() caused a type mismatch. Removed note_id from the column list and gen_random_uuid() from the SELECT — the SERIAL generates the PK automatically and RETURNING note_id still returns the correct integer value.
… rejects WITH...INSERT) The data-modifying CTE (WITH distinct_addresses AS (...) INSERT INTO...) is not valid PL/pgSQL syntax; it caused syntax error at position 2976 on CI's PostgreSQL. Rewrote as a plain INSERT INTO...SELECT with the multi-source UNION dblink query inlined directly using string concatenation to avoid multi-line $$ conflicts inside the $etl$ DO block.
web package has no unit tests yet; vitest exits 1 on empty test suite by default. Add passWithNoTests: true so CI passes until tests are added.
…s as dq_finding
Adds EDW_TO_VSAC_CODE_SYSTEM map to vsacService.ts and a new
code_system_contract detector in dqDetectors.ts that runs as part of
runDqScan(). The detector checks phm_edw.condition and phm_edw.procedure
(small tables; full scans safe) against the map and fires:
- warning: code_system value absent from EDW_TO_VSAC_CODE_SYSTEM entirely
(unknown label — schema drift or mis-ingest)
- warning: code_system is null-mapped (ICD-9, OTHER) and rows exist
(cannot reconcile against VSAC eCQM extracts)
- warning: code_system maps to a VSAC label but LIMIT-500 sample join
against vsac_value_set_code yields zero overlap with >100 EDW rows
- info: condition rows with code_system='ICD-10' (column DEFAULT) but
condition_code matching ^[0-9]+$ (SNOMED-shaped — default mislabel hazard)
phm_edw.observation is explicitly out of scope (~1B rows, no code_system
column). entity_id=0 is a sentinel for aggregate findings so ON CONFLICT
dedup works across re-runs without a real PK.
Live scan on 2026-06-12 data: zero warnings (SNOMED→SNOMEDCT overlaps fine).
Rollback test confirmed: inserting OTHER row inside a BEGIN/ROLLBACK shows
code_system=OTHER|1 in the detector SQL; live table unchanged (SNOMED|324).
Tests: 9 new mocked-unit tests covering all firing/non-firing paths.
Suite: 175/22 (was 166/21). tsc: clean.
…sponses
- GET /value-sets/measure/:measureCode now calls getMeasureBridgeStatus()
alongside getMeasureValueSets(); 404 gates on status===null; response
shape is { status, value_sets } so consumers see version_drift and
unclassified_count on every bridged measure.
- getValueSetCodes: adds LIMIT 12000 (max loaded expansion is 11,539 codes,
verified 2026-06-12); pagination deferred as future work.
- GET /measures/:id/strata: adds small_cell: denominator > 0 && denominator < 11
to every row — display guidance for wide-CI small-n strata, not suppression;
raw n remains visible (internal clinical tool).
…inical-fidelity-hardening
…aded Fresh environments (CI) run migrations before the manual VSAC load; the hard gate now fires only on the real failure mode (data loaded but value-set names drifted). Full chain re-validated on a scratch database.
…ng + CI restoration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the measure/VSAC layer clinically TRUE, per the adversarial safety review of PR #1:
denominator_exclusion/initial_population/supplemental/unclassified(conservative name heuristic,role_methodprovenance, manual-override ready).resolveMeasureCodesnow requires a role — the 82% exclusion-code contamination found in review (CMS122: 2,114 hospice/advanced-illness/frailty codes unioned with 30 encounter codes) is structurally impossible to consume by accident. During implementation a heuristic bug was caught pre-run: nursing-facility encounter sets would have been misclassified as exclusions, silently suppressing care gaps for nursing-facility patients across 9 measures.gap_status='excluded'was fabricated by a deterministic hash (migration 017) — 2,689 rows with zero clinical input. Now computed nightly from the imported exclusion-family value sets with provenance comments; all 2,689 unevidenced rows reverted toopen. Measure rates change with this merge — downward, honestly (care_gapnow: closed=6,697 / open=20,270 / excluded=0 until real exclusion evidence exists). That is the point, not a regression.ACE Inhibitor or ARB or ARNIRxNorm value set (151 codes) bound throughclinical_rule(transparency endpoint explains it), plus allergy/intolerance suppression the regex could never express. Flag counts stable (367→367) — convergence evidence, with indexed query plans verified.EDW_TO_VSAC_CODE_SYSTEMtranslation map (SNOMED→SNOMEDCT,ICD-10→ICD10CM) + a Phase 7-style detector that flags unmapped/zero-overlap code systems and SNOMED-shaped codes mislabeled by the'ICD-10'column default. Inert on today's data; fire-tested via rolled-back probe.GET /value-sets/measure/:codenow returns bridge status (version_drift: truefor all 44 v12↔v14 bridges, role coverage, unclassified count); strata rows carrysmall_cell(n<11) display guidance; value-set codes endpoint bounded (LIMIT 12,000 — largest real expansion is 11,539).Contains PR #1 and PR #2
This branch merges
feature/cds-vsac-value-sets(#1, VSAC asset) andfix/ci-pipeline(#2, CI restoration) — merging this PR lands all three. Alternatively merge in order #2 → #1 → this for granular history; #1/#2 then reduce to no-ops.Test plan
turbo typecheck+turbo lintgreen locally🤖 Generated with Claude Code